import net from 'net'
import os from 'os'

class Locked extends Error {
  constructor (port: any) {
    super(`${port} is locked`)
  }
}

const lockedPorts = {
  old: new Set(),
  young: new Set()
}

// On this interval, the old locked ports are discarded,
// the young locked ports are moved to old locked ports,
// and a new young set for locked ports are created.
const releaseOldLockedPortsIntervalMs = 1000 * 15

const minPort = 1024
const maxPort = 65535

// Lazily create interval on first use
let interval: NodeJS.Timeout | undefined

const getLocalHosts = () => {
  const interfaces = os.networkInterfaces()

  // Add undefined value for createServer function to use default host,
  // and default IPv4 host in case createServer defaults to IPv6.
  const results = new Set([undefined, '0.0.0.0'])

  for (const _interface of Object.values(interfaces)) {
    for (const config of _interface) {
      results.add(config.address)
    }
  }

  return results
}

const checkAvailablePort = (options: { port: any; host: any }) =>
  new Promise((resolve, reject) => {
    const server = net.createServer()
    server.unref()
    server.on('error', reject)

    server.listen(options, () => {
      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
      // @ts-ignore
      const {port} = server.address()
      server.close(() => {
        resolve(port)
      })
    })
  })

const getAvailablePort = async (options: { host: any; port: any }, hosts: Set<string | undefined>) => {
  if (options.host || options.port === 0) {
    return checkAvailablePort(options)
  }

  // @ts-ignore
  for (const host of hosts) {
    try {
      await checkAvailablePort({port: options.port, host})
    } catch (error) {
      if (!['EADDRNOTAVAIL', 'EINVAL'].includes(error.code)) {
        throw error
      }
    }
  }

  return options.port
}

const portCheckSequence = function * (ports: any) {
  if (ports) {
    yield * ports
  }

  yield 0 // Fall back to 0 if anything else failed
}

export default async function getPorts (options: { port: any; exclude?: any; host?: any } | null) {
  let ports
  let exclude = new Set()

  if (options) {
    if (options.port) {
      ports = typeof options.port === 'number' ? [options.port] : options.port
    }

    if (options.exclude) {
      const excludeIterable = options.exclude

      if (typeof excludeIterable[Symbol.iterator] !== 'function') {
        throw new TypeError('The `exclude` option must be an iterable.')
      }

      for (const element of excludeIterable) {
        if (typeof element !== 'number') {
          throw new TypeError('Each item in the `exclude` option must be a number corresponding to the port you want excluded.')
        }

        if (!Number.isSafeInteger(element)) {
          throw new TypeError(`Number ${element} in the exclude option is not a safe integer and can't be used`)
        }
      }

      exclude = new Set(excludeIterable)
    }
  }

  if (interval === undefined) {
    interval = setInterval(() => {
      lockedPorts.old = lockedPorts.young
      lockedPorts.young = new Set()
    }, releaseOldLockedPortsIntervalMs)

    // Does not exist in some environments (Electron, Jest jsdom env, browser, etc).
    if (interval.unref) {
      interval.unref()
    }
  }

  const hosts = getLocalHosts()

  // @ts-ignore
  for (const port of portCheckSequence(ports)) {
    try {
      if (exclude.has(port)) {
        continue
      }
      // @ts-ignore
      let availablePort = await getAvailablePort({...options, port}, hosts)
      while (lockedPorts.old.has(availablePort) || lockedPorts.young.has(availablePort)) {
        if (port !== 0) {
          throw new Locked(port)
        }
        // @ts-ignore
        availablePort = await getAvailablePort({...options, port}, hosts)
      }

      lockedPorts.young.add(availablePort)

      return availablePort
    } catch (error) {
      if (!['EADDRINUSE', 'EACCES'].includes(error.code) && !(error instanceof Locked)) {
        throw error
      }
    }
  }

  throw new Error('No available ports found')
}

export function portNumbers (from: any, to: any) {
  if (!Number.isInteger(from) || !Number.isInteger(to)) {
    throw new TypeError('`from` and `to` must be integer numbers')
  }

  if (from < minPort || from > maxPort) {
    throw new RangeError(`'from' must be between ${minPort} and ${maxPort}`)
  }

  if (to < minPort || to > maxPort) {
    throw new RangeError(`'to' must be between ${minPort} and ${maxPort}`)
  }

  if (from > to) {
    throw new RangeError('`to` must be greater than or equal to `from`')
  }

  const generator = function * (from: any, to: number) {
    for (let port = from; port <= to; port++) {
      yield port
    }
  }

  return generator(from, to)
}
